Skip to content

feature: add one-cancels-the-other (OCO) order group - #9635

Closed
Romazes wants to merge 21 commits into
QuantConnect:masterfrom
Romazes:feature-8253-oco-order
Closed

Romazes wants to merge 21 commits into
QuantConnect:masterfrom
Romazes:feature-8253-oco-order

Conversation

@Romazes

@Romazes Romazes commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Adds a one-cancels-the-other (OCO) order group. QCAlgorithm.OneCancelsTheOtherOrder places a take profit limit leg and a stop loss stop market leg together, both for the same quantity. Both legs rest in the market; the first one to execute ends the group.

C#:

MarketOrder(_spy, 100);
var price = Securities[_spy].Price;

// close the position at a profit or at a loss, whichever price comes first
var tickets = OneCancelsTheOtherOrder(_spy, -100, limitPrice: price * 1.01m, stopPrice: price * 0.95m);

Python:

self.market_order(self._spy, 100)
price = self.securities[self._spy].price

tickets = self.one_cancels_the_other_order(self._spy, -100, limit_price=price * 1.01, stop_price=price * 0.95)

The group can never trade more than the quantity it was given. When a leg executes, that quantity comes off every other open leg, and a leg left with nothing is canceled in the same order event batch. A full fill is just the case where the others reach zero. This also covers a partial fill: the stop selling 30 of 100 leaves the limit leg at 70, so the position keeps its target and the group never sells 130.

Main changes:

  • New GroupExecutionType enum (Combo, OneCancelsTheOther) and GroupOrderManager.ExecutionType, serialized as executionType. Groups serialized before this keep loading as Combo.
  • Order.CreateOrder and OrderJsonConverter attach the group manager to a plain Limit/StopMarket leg before its Id is set, so a leg restored from JSON stays in its group. Only combo order types carried the manager through those paths before.
  • QCAlgorithm.SubmitGroupOrder is the shared submitter for group types that are not the ratio combo: it builds the legs' requests, runs every pre-order check before submitting any leg, and submits in list order. The conditional (OTO) and bracket orders add their own thin wrapper over it.
  • BacktestingBrokerage simulates the group so backtesting and paper trading need no brokerage support. Legs are evaluated in a fixed order — stop legs first, then by id — and the first leg to execute any quantity ends the pass and reduces its siblings.
  • Buying power does not double count the legs. A same symbol group only has to afford its most expensive leg, since only one leg can execute; a group across symbols checks every leg on its own. CashBuyingPowerModel stops counting a sibling's quantity as reserved, and GetProjectedHoldings and Shortable count one leg per group.
  • HandleUpdateOrderRequest now validates buying power when a leg of a non-combo group is updated. Combo legs keep the old skip.
  • No brokerage gate. A brokerage maps the group in its own plugin, and AlpacaBrokerageModel.CanSubmitOrder checks Alpaca's own rules per leg: 2 legs, US equity, limit or stop market, both legs on the same side, day or good til canceled.

Related PR(s)

Both are drafts that depend on this one:

Related Issue

#8253

Motivation and Context

First step of the bracket order plan: OCO now, conditional (OTO) next, bracket on top of both. A user can protect a position with a take profit and a stop loss pair without canceling the other one by hand, and without the risk of both filling.

Requires Documentation Change

Yes. The new OneCancelsTheOtherOrder API needs a page, and the brokerage pages need to say which brokerages accept the group once the two plugin pull requests land.

How Has This Been Tested?

Regression algorithms on SPY hourly, January 2019:

  • OneCancelsTheOtherOrderRegressionAlgorithm (C# and Python) — the take profit leg fills, the stop leg ends Canceled in the same batch, and the algorithm ends flat.
  • OneCancelsTheOtherOrderCancelRegressionAlgorithm (C# and Python) — canceling one leg cancels both, and the original market order fill is untouched.
  • OneCancelsTheOtherOrderPartialFillRegressionAlgorithm (C# only, it needs a custom fill model) — the stop leg executes 30 of 100 and the limit leg has to shrink to 70. It asserts the leg quantity, not just the total, because a group that stopped early would also end flat.
  • OneCancelsTheOtherOrderDemoAlgorithm — a manual aid for running the group against a live or paper brokerage. It does not implement IRegressionAlgorithmDefinition.

Unit tests:

  • BacktestingBrokerageTests: LegFillCancelsSiblingInSameEventBatch, StopLegWinsTieOverLimitLeg, CancelingOneLegCancelsWholeGroup, CancelOrderLeavesAlreadyClosedLegUntouched, TimeInForceExpiryOnAnyLegCancelsWholeGroup, PartialFillReducesSiblingsAndGroupStaysPending, GroupIsProcessedOnlyOnceExactlyPerScanDespiteTwoPendingEntries.
  • AlgorithmTradingTests: the two tickets come back limit first and share one manager; a zero quantity returns one invalid ticket; the tag and order properties reach both legs; Liquidate cancels every leg of an open group.
  • BrokerageTransactionHandlerTests: the legs buffer until the group is complete, a submit time failure invalidates every leg, and the update path validates buying power for OCO but not for combo.
  • SecurityPortfolioManagerTests and CashBuyingPowerModelTests: the most expensive leg rule, and a cash account holding exactly 1 BTC can place the sell pair through OneCancelsTheOtherOrder.
  • OrderJsonConverterTests and OrderTests: a plain leg keeps its group over two round trips, and old JSON without the field loads as Combo.
  • AlpacaBrokerageModelTests: a valid group passes, and each of the five per-leg rules rejects on its own.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • Refactor (non-breaking change which improves implementation)
  • Performance (non-breaking change which improves performance)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Non-functional change (xml comments/documentation/etc)

Checklist:

  • My code follows the code style of this project.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • My branch follows the naming convention bug-<issue#>-<description> or feature-<issue#>-<description>

@Romazes
Romazes marked this pull request as draft July 24, 2026 15:34
@Romazes
Romazes force-pushed the feature-8253-oco-order branch from 99e04e7 to 05ad8b1 Compare July 28, 2026 20:34
@Romazes Romazes self-assigned this Jul 29, 2026
Romazes added 20 commits August 20, 2026 22:39
- add ComboType to GroupOrderManager and the OneCancelsTheOtherOrder api
- gate live order groups behind BrokerageModel.SupportsGroupExecution
- simulate group fill and sibling cancel in the backtesting brokerage
- count one leg per group in buying power and open order aggregations
- carry the group manager through the order factory and json round trip
- end the group pass on any fill, partial as well as complete
- take the executed quantity off every other open leg instead of only
  canceling on a complete fill, and cancel a leg left with nothing to execute
- add a regression algorithm that fails without the fix
- assert the sibling reduction in the backtesting brokerage tests
- add a second group that buys 100 and is won by its stop market leg
- sell 200 in the first group so holdings reverse through zero
- open the position on its own bar and drop the price rounding the
  transaction handler already does
- find the legs by order type instead of by list index
- reject a group that is not exactly 2 legs, us equity, limit plus stop market, one side, day or gtc tif
- add the five brokerage messages for those rejections
- cover the valid group and every rejection reason, and make the group execution gate test able to fail
- move the enum to its own file Common/Orders/GroupExecutionType.cs
- rename GroupOrderManager.ComboType to ExecutionType, json name to executionType
- lean already rounds order prices before sending them
- mirror the change in the python twin of the cancel algorithm
- PreOrderChecksImpl already answers ZeroQuantity, so the group comes back as one invalid ticket
- assert the invalid ticket instead of the exception
- drop the per group skip so every open order gets its own cancel, like CancelOpenOrders
- a leg its siblings already canceled answers with an error response instead of throwing
- remove SupportsGroupExecution from IBrokerageModel, DefaultBrokerageModel, alpaca, ib and the python wrapper
- remove the live mode check from the transaction handler and every test that covered it
- restore PythonWrapper as it was: no default bodied interface method is left to skip
- places a market entry and a 2 leg oco exit, logs every order event
- manual live/paper testing aid, it does not implement IRegressionAlgorithmDefinition
- ProcessOneCancelsTheOtherGroup returns true when the group must be evaluated again, no ref parameter
- shorten the comments to the reason behind the code
- CompositeLogHandler and BrokerageTests go back to master, they are not part of the order group work
- restore the group manager comments to one line each in Order and OrderJsonConverter
- place the cash buying power oco test through OneCancelsTheOtherOrder instead of hand built requests
@Romazes
Romazes force-pushed the feature-8253-oco-order branch from 9df6d96 to cdfd68c Compare August 20, 2026 19:40
- one line each in CashBuyingPowerModel, SecurityPortfolioManager and BrokerageTransactionHandler
@Romazes
Romazes marked this pull request as ready for review August 24, 2026 16:56
@Romazes

Romazes commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Design notes behind this PR, part 1 of 2. ADR 0001 is the research step: it checks every Lean.Brokerages.* plugin for native bracket support and concludes that a bracket is two smaller pieces, OCO and OTO. That is why this PR ships OCO on its own first.

ADR 0001: Bracket order type

ADR 0001: Bracket order type

Status

Proposed - 2026-07-09

Purpose

Implement a new OrderType.Bracket in Lean core, plus the brokerage-side mapping in the plugins whose API supports it. This file is the working document for that implementation: it holds the broker research and the reference PRs to follow.

Context

A bracket order is an entry (parent) order with two attached exit orders: a take-profit limit and a stop-loss. The two exits are OCO (one cancels the other). Lean users ask for this feature in live trading, but each brokerage API supports it differently: some have a native bracket/OTOCO order type, some only allow one attached exit, some only support standalone TP/SL orders, and some do not support it at all.

Before designing the Lean-side API, we researched every brokerage that has a Lean.Brokerages.* plugin and checked whether the broker's own trading API natively supports bracket orders. Sources are official broker/exchange documentation pages, fetched and verified.

Classification used in the table:

  • yes - native API support for parent + take-profit + stop-loss
  • no - no bracket support in the API

Broker API support matrix

brokerage bracket source (official doc link) notes
InteractiveBrokers yes https://interactivebrokers.github.io/tws-api/bracket_order.html Parent + TP limit + SL via parentId
Alpaca yes https://docs.alpaca.markets/us/docs/orders-at-alpaca#bracket-orders order_class=bracket, exits are OCO; no extended hours, TIF must be day/gtc
Tradier yes https://docs.tradier.com/reference/advanced-orders Native OTOCO endpoint; equities and options; legs sent in one request
Tastytrade yes https://developer.tastytrade.com/open-api-spec/orders/, https://developer.tastytrade.com/order-management/#submit-complex-order POST /accounts/{n}/complex-orders, type OTOCO; docs call it a bracket
TradeStation yes https://api.tradestation.com/docs/specification#tag/Order-Execution/operation/PlaceGroupOrder OSO + OCO order types; stocks, options, futures; endpoint schema is deeper in the API spec
WeBull yes https://developer.webull.com/apis/docs/trade-api/stock/ combo_type enum includes OTO, OCO, OTOCO and MASTER (entry + TP/SL legs)
Coinbase yes https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/guides/orders attached_order_configuration with trigger_bracket_gtc/gtd; one exit fills, the other cancels
Binance yes (spot) https://developers.binance.com/docs/binance-spot-api-docs/rest-api/trading-endpoints Spot OTOCO order list POST /api/v3/orderList/otoco; entry must be LIMIT/LIMIT_MAKER (no market-entry bracket). Futures API not verified yet - TP/SL there are separate conditional orders
TradingTechnologies yes https://library.tradingtechnologies.com/trade/tto-bracket-order.html TT Bracket: parent limit/stop + OCO child pair with fill-size matching; page is platform help - TT REST/FIX reference still to confirm API-level
CharlesSchwab yes (docs gated) https://developer.schwab.com/ Order schema (orderStrategyType TRIGGER/OCO, i.e. 1st-triggers-OCO) is behind developer login; public portal does not show it
Bybit no https://bybit-exchange.github.io/docs/v5/order/create-order [Need deeply read doc] takeProfit/stopLoss attached on create (spot + derivatives); tpslMode Full/Partial; options get market-only exits; docs never say "OCO" explicitly
Kraken no https://docs.kraken.com/api/docs/rest-api/add-order/ Conditional close attaches only ONE exit (close[ordertype]) - cannot attach both TP and SL, no OCO link
Bitfinex no https://docs.bitfinex.com/reference/ws-auth-input-order-new price_oco_stop gives a limit+stop OCO pair, but no parent entry that triggers exits (no OTOCO)
dYdX no https://docs.dydx.xyz/concepts/trading/orders Standalone TP/SL conditional orders for closing positions; no OCO/bracket linkage documented
Public.com no https://public.com/api/docs Any mentions in doc
Zerodha no https://support.zerodha.com/category/trading-and-markets/trading-faqs/general/articles/why-bo-stopped Bracket orders discontinued March 2020, confirmed still gone by Kite staff (2021); no API bracket
RBI (Raiffeisen) no - Institutional FIX connection; nothing publicly documented to cite
Wolverine (WEX) no - Institutional FIX; only an algo help page found so far
OANDA no https://developer.oanda.com/rest-live-v20/order-df/ takeProfitOnFill + stopLossOnFill; exits are bound to the trade, so OCO effect comes from trade close
Eze (SS&C) no https://emsportal.ezesoft.com/pdf/EMS%20xAPI%20Technical%20Documentation.pdf (candidate) EMS xAPI PDF found by search, not verified yet
TerminalLink (Bloomberg EMSX) no https://emsx-api-doc.readthedocs.io/ (candidate) EMSX API docs found, not verified yet
Samco no - Likely same SEBI-driven removal as Zerodha; needs confirmation
Rithmic no https://www.rithmic.com/apis Server-side brackets and OCOs in R|API+ and R|Protocol; exits fire even if the client disconnects; field-level docs are gated

OCO / OTO primitive support matrix

A bracket is not a broker primitive on its own; it decomposes into two generic mechanisms that most brokers already expose separately or in combination:

  • OTO (One-Triggers-Other / conditional): an order that, once filled (or once a price triggers), submits/activates another order.
  • OCO (One-Cancels-Other): a linked pair/group where filling one leg cancels the rest.
  • A bracket is the composition OTO → OCO (entry triggers an OCO exit pair); brokers usually call this OTOCO / 1st-Trigger-OCO / Bracket.

This table records, per brokerage, whether OCO and OTO/conditional are available standalone (their own order class) and whether a combined OTOCO/bracket exists. Legend: yes / no / partial (only in a limited or attached form) / — (unknown / not publicly documented).

brokerage OCO standalone OTO / conditional standalone combined (OTOCO / bracket) native name(s) source (official doc link)
InteractiveBrokers yes yes yes OCA group, parentId, Conditional Orders, Bracket https://interactivebrokers.github.io/tws-api/oca.html
Alpaca yes yes yes order_class = oco / oto / bracket (equities only) https://alpaca.markets/blog/oco-oto/
Tradier yes yes yes class = oco / oto / otoco https://documentation.tradier.com/brokerage-api/trading/place-otoco-order
Tastytrade yes no (only inside OTOCO) yes complex-order type OCO / OTOCO https://developer.tastytrade.com/order-management/#submit-complex-order
TradeStation yes yes (OSO) yes (BRK) group type OCO / OSO / BRK https://help.tradestation.com/09_01/tradestationhelp/ob/about_oco_oso_orders.htm
WeBull yes yes yes combo_type = OTO / OCO / OTOCO / MASTER (US equities) https://developer.webull.com/apis/docs/trade-api/stock/
Coinbase no no yes (attached only) attached_order_configuration trigger_bracket_gtc/gtd https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/guides/orders
Binance (spot) yes yes yes order list oco / oto / otoco (spot only) https://developers.binance.com/docs/binance-spot-api-docs/rest-api/trading-endpoints
TradingTechnologies yes yes (OSO) yes TT OCO / OSO / Bracket (platform; REST/FIX exposure TBC) https://library.tradingtechnologies.com/trade/tto-oco-order.html
CharlesSchwab yes yes (TRIGGER) yes (TRIGGER→OCO) orderStrategyType = OCO / TRIGGER (docs gated) https://developer.schwab.com/
Bybit partial (attached TP/SL on position) partial (conditional/trigger orders) yes (attached TP/SL) attached takeProfit/stopLoss (tpslMode), conditional order https://bybit-exchange.github.io/docs/v5/order/create-order
Kraken derivatives only yes (Conditional Close, spot) derivatives TP/SL bracket Conditional Close (OTO); futures TP/SL (OCO) https://support.kraken.com/articles/360038640052-conditional-close
Bitfinex yes no no OCO (limit + stop pair) https://support.bitfinex.com/hc/en-us/articles/115003507305-What-is-a-One-Cancels-Other-OCO-order-option-on-Bitfinex
dYdX no partial (price-triggered conditional only, no order→order link) no conditional stop / take-profit orders https://help.dydx.trade/en/articles/166981-perpetual-order-types-on-dydx-chain
Public.com no no no — https://public.com/api/docs
Zerodha yes (GTT two-leg) partial (GTT single-leg conditional; intraday BO discontinued 2020) no GTT OCO (two-leg) https://kite.trade/docs/connect/v3/gtt/
RBI (Raiffeisen) — — — institutional FIX (undocumented) -
Wolverine (WEX) — — — institutional FIX (undocumented) -
OANDA trade-level only yes (takeProfitOnFill / stopLossOnFill) yes (entry + TP/SL on fill) takeProfitOnFill / stopLossOnFill https://developer.oanda.com/rest-live-v20/order-df/
Eze (SS&C) — — — EMS xAPI (unverified) -
TerminalLink (Bloomberg EMSX) — — — EMSX API (unverified) -
Samco — — — SEBI-driven removal like Zerodha BO; unverified -
Rithmic yes (server-side) via bracket yes (server-side bracket) server-side OCO / Bracket (R|API+) https://www.rithmic.com/apis

Takeaway: the composable OCO + OTO model is the norm, not the exception. Seven brokers expose OCO and OTO/OSO/TRIGGER as distinct order classes with OTOCO/bracket as the explicit combination (InteractiveBrokers, Alpaca, Tradier, Binance spot, WeBull, TradeStation, CharlesSchwab). Standalone OCO is broadly available beyond those (Bitfinex, Zerodha GTT, TradingTechnologies, Rithmic, Tastytrade). Only a minority are bracket-/attach-only (Coinbase, OANDA, Bybit) or single-conditional-only (dYdX, Kraken spot). This supports implementing OCO and conditional (OTO) as independent Lean primitives, with bracket as a thin OTOCO composition on top.

Reference implementations

Earlier PRs that added a new order type to Lean. They show every place a new order type must touch: OrderType enum, order class in Common/Orders, serialization, brokerage models, fill models, Python bindings, and tests.

@Romazes

Romazes commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Part 2 of 2. ADR 0002 is the decision record for the OCO order group in this PR: the API shape, the engine rule for partial fills, buying power, what each brokerage allows, the live mappings, and the known gaps.

ADR 0002: One-Cancels-the-Other (OCO) order group

ADR 0002: One-Cancels-the-Other (OCO) order group

Status

Accepted - 2026-08-20 (proposed 2026-07-16).

What exists today:

Part Where State
Lean core Lean, branch feature-8253-oco-order PR #9635, 28 files against master
Alpaca live mapping Lean.Brokerages.Alpaca, branch feature-alpaca-oco-orders draft PR #74, live paper run still to do
InteractiveBrokers live mapping Lean.Brokerages.InteractiveBrokers, branch feature-oco-orders draft PR #244, run against a live IB Gateway

Companion documents: 0002-implementation-review.md is the code review of this
implementation, 0002-open-question-group-scope.md is the open question about
how wide the public API should be, and 0003-one-triggers-the-other-order-group.md
is the next step in the series, which builds on everything here.

Purpose

ADR 0001 research showed that a bracket order is not one thing. It is two smaller, generic pieces:

  • OCO (One-Cancels-the-Other): linked orders where one fill cancels the rest.
  • OTO (One-Triggers-the-Other / conditional): an order that submits another order when it fills.

The team decision is to build the two pieces separately and then build the bracket as a thin layer on top:
PR 1 = OCO (this document) -> PR 2 = conditional (OTO) -> PR 3 = bracket (sugar + small wiring).

One extra goal shaped every choice: the OCO code must be built from pieces that OTO and the bracket can
reuse.
So this work does not just add an OCO feature — it adds the group-order building blocks (one enum,
one gate, one submitter, one set of engine steps), and OCO is only the first consumer. The section
"Built to be reused by OTO and the bracket" lists each block and who reuses it.

What is an OCO order group

The simple picture:

  1. You place two or three orders at the same time.
  2. The orders are linked together in one group.
  3. All orders in the group are live in the market at the same time. Nobody waits for anybody.
  4. When one order fills, the broker cancels every other order in the group.
  5. So exactly one order from the group can win. The rest disappear.

A story example. You own 100 AAPL bought at $200. You want to take profit at $220, but you also want
protection if the price falls to $190. You place two sell orders in one OCO group:

  • sell 100 AAPL, limit $220 (take profit)
  • sell 100 AAPL, stop $190 (stop loss)

If the price reaches $220 first, the limit order fills and the stop order is canceled. If the price falls to
$190 first, the stop order fills and the limit order is canceled. You can never sell 200 shares by mistake,
because the group can only ever sell the 100 shares it was given. If one leg fills only part of that, the
other leg is made smaller by the same amount instead of being canceled — see "Partial fills" below.

Important difference from the next PR: in an OCO group no order waits for another order to fill. The
"wait until my parent fills" behavior is the conditional (OTO) order — that is PR 2. A bracket is the
combination: OTO entry that triggers an OCO exit pair.

What brokers allow

How wide a group each brokerage accepts. "Legs" is how many orders fit in one group, and the last three
columns say whether the legs may differ from each other. Checked August 2026.

brokerage legs order types symbols sides source
InteractiveBrokers no documented cap; the TWS panel shows 1 main + 4 attached any any, even different asset classes any OCA docs
TradeStation no limit, at least 2 any any any OCO and OSO orders
Alpaca exactly 2 1 limit take profit + 1 stop or stop-limit stop loss one one orders guide
Binance (spot) exactly 2, an "above" and a "below" order one leg LIMIT_MAKER/TAKE_PROFIT/TAKE_PROFIT_LIMIT, the other STOP_LOSS/STOP_LOSS_LIMIT one, a single symbol field one, a single side field order list OCO
Tradier exactly 2 any, but "the type must be different for both legs" one, and both legs must be the same asset class: equity legs share symbol, option legs share option_symbol not restricted in the docs advanced orders
Tastytrade 2 in the documented example, no cap stated limit + stop-limit in the example one underlying one, both legs close the position complex orders
WeBull 1 main + 5 children, so 6; the API example shows 3 legs any: the desktop app mixes Limit, Stop and Stop Limit in one group any: 5 symbols in one group any: buys and sells in one group desktop app, plus trade API for combo_type=OCO
Zerodha (GTT two-leg) exactly 2 LIMIT only not stated; the example uses one not stated; the example uses two sells Kite GTT
Bitfinex not a group: one limit order carrying a price_oco_stop field limit + stop, fixed one one OCO orders
CharlesSchwab childOrderStrategies array, no cap stated; every example uses 2 any, each child carries its own orderType; the example pairs LIMIT with STOP_LIMIT not stated, each child carries its own instrument not stated, each child carries its own instruction Trader API docs, "Conditional Order: One Cancels Another"
Rithmic unknown, server-side OCO unknown unknown unknown field-level docs gated
TradingTechnologies unknown unknown unknown unknown platform help only, REST/FIX exposure unconfirmed

Three shapes come out of this.

InteractiveBrokers, TradeStation and WeBull let the legs differ in every way: order type, symbol and side.
TradeStation's own docs give the example of a limit buy on one symbol paired with a stop sell on another. IB
and TradeStation put no cap on the leg count; WeBull caps the group at 6.

CharlesSchwab is in between: its OCO is a wrapper around a list of complete orders, each with its own
symbol, side, type and duration, so nothing in the schema forces them to match — but no rule says they may
differ either, and every documented example is a same-symbol same-side pair.

Alpaca, Binance spot, Tradier, Tastytrade and Zerodha are a fixed take profit + stop loss pair on one
symbol and one side. Bitfinex is not even a group — the OCO is a second price on a single limit order.

Verified beyond the docs: a 5-leg group placed through IB Gateway (see "Live mapping: InteractiveBrokers").

The mixed rows for InteractiveBrokers and WeBull come from their desktop platforms, not from an API call. The
TWS panel took AAPL limit + AAPL market + NOK stop-limit + F trailing stop in one group. The WeBull order
entry screen took six legs across five symbols — ELVA limit buy, AAPL stop sell, F stop-limit buy, NVDA
stop-limit sell, ELVA stop-limit buy and NOK limit buy. Neither platform's rules are written down in the API
docs, so treat the API as unconfirmed on those two rows.

The CharlesSchwab row comes from the Trader API documentation saved locally (October 2024 snapshot), since the
developer portal needs a login. Its OCO example is two SELL orders on one symbol, LIMIT 45.97 and
STOP_LIMIT 37.03/37.00, both DAY. The same page nests an OCO inside a TRIGGER order, which is how Schwab
builds a bracket.

One useful thing the APIs show: an OCO group is really flat. Every leg is equal; nobody is the boss. The
"main order + children" look is only how the platforms draw it on screen. Lean keeps the flat model too — the
first order in the list only provides the group's quantity and direction, nothing more.

What this means for the Lean API: we are narrower than every brokerage in the table.
OneCancelsTheOtherOrder builds exactly 2 legs on one symbol, and the engine only accepts Limit and
StopMarket. That is deliberate for v1 and matches the strictest brokerages, but it means IB and TradeStation
users cannot reach what their brokerage allows. See
0002-open-question-group-scope.md.

Which brokerages have a native OCO order class at all, with the OTO and bracket columns beside it, is in the
"OCO / OTO primitive support matrix" in ADR 0001.

Scope for v1

  • One OCO group from the user API = 2 orders: one limit leg and one stop market leg on one symbol, sharing
    one quantity
    . The engine underneath works on a list of legs and has no leg-count guard, because the final
    goal (bracket) needs an OCO pair plus an entry.
  • Leg order types in v1: Limit and StopMarket. These are the two types a bracket needs.
  • Legs may use different symbols at the engine level (the buying-power check has its own branch for that).
    The user API takes one symbol; a multi-symbol overload can come later.
  • Backtesting and paper trading work with zero brokerage changes. Live support is opt-in per brokerage model.

Whether the public API should stay this narrow is still open — see
0002-open-question-group-scope.md.

Algorithm API

One new method on QCAlgorithm (Algorithm/QCAlgorithm.Trading.cs):

public List<OrderTicket> OneCancelsTheOtherOrder(Symbol symbol, decimal quantity, decimal limitPrice, decimal stopPrice,
    bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null)

The method builds the two legs itself — a LimitOrder and a StopMarketOrder for the same signed quantity,
both stamped with UtcTime — links them and submits them. The result is two tickets: the limit leg first, the
stop market leg second. When a pre-order check fails, nothing is placed and the list contains a single invalid
ticket (the same failure shape combo orders have today).

Decision: the input is scalar parameters, not a list of orders. A List<Order> input was implemented
first, and a List<Leg> alternative was considered before it. Both put engine order objects in the user's
hands, and order objects carry engine-managed fields (status, broker ids, group manager), a time constructor
argument the engine must ignore, and per-order tag/properties that fight with the group-level ones. Covering
that needed a page of "fresh spec" rules and seven ArgumentException guards. Scalar parameters remove the
whole problem:

  • The user never touches an Order object. The method builds the legs internally, so there is no reused
    live order, no stale time, no per-leg tag or TIF to override — one source of truth by construction.
  • Same shape as the rest of the order sugar. LimitOrder(symbol, quantity, limitPrice) and
    StopLimitOrder(symbol, quantity, stopPrice, limitPrice) read the same way, and the future
    BracketOrder(symbol, quantity, takeProfitLimitPrice, stopLossPrice, ...) continues the pattern.
  • Python-friendly. self.one_cancels_the_other_order(symbol, -100, limit_price=..., stop_price=...)
    through the snake-case binding — no C# order construction from Python.

Starting example:

public override void OnData(Slice slice)
{
    if (!Portfolio.Invested)
    {
        MarketOrder(_symbol, 100);

        // close the position either with profit at 220 or with protection at 190
        var tickets = OneCancelsTheOtherOrder(_symbol, -100, limitPrice: 220m, stopPrice: 190m);
    }
}

The method has no validation of its own. A zero quantity is already rejected by PreOrderChecksImpl,
which answers OrderResponse.ZeroQuantity, so the group comes back as one invalid ticket like any other
failed pre-order check. Everything the list input had to check — leg count, leg types, fresh specs, duplicate
instances — cannot be expressed with this signature. There is also no "same time in force" check: the group's
TIF comes from the single orderProperties, so mixed TIF cannot happen.

All-or-nothing submit: the pre-order checks run for every leg before any leg is submitted (same pattern as
SubmitComboOrder). If one check fails, nothing is placed and one invalid ticket is returned.

Internally the public method is a thin wrapper over one shared private submitter that the next PRs reuse:

// one submitter for every group type that is not the existing ratio-based combo; OCO is the first caller
private List<OrderTicket> SubmitGroupOrder(GroupExecutionType groupExecutionType, List<Order> orders,
    bool asynchronous, string tag, IOrderProperties orderProperties)

SubmitGroupOrder owns the shared steps: building the GroupOrderManager (its quantity comes from
orders[0]), reading each leg's prices through the new Order.GetOrderPrices extension, turning each leg into
a SubmitOrderRequest, running all pre-order checks before the first submit, and submitting in list order.
It does not wait for fills: an OCO group has nothing that fills at submit time, resting legs stay open by
design, so a synchronous call has nothing to wait for. When the bracket arrives, the wait becomes
trigger-aware: wait on the market-type trigger leg only. Per-type rules (for example the bracket's
"take profit above entry above stop loss" price checks) stay in each public wrapper. Planned wrappers over the
same submitter:

// ADR 0003 (conditional):
public List<OrderTicket> OneTriggersTheOtherOrder(Order trigger, List<Order> children, ...)

// final PR (bracket sugar): builds entry + take profit + stop loss and calls SubmitGroupOrder(GroupExecutionType.Bracket, ...)
public List<OrderTicket> BracketOrder(Symbol symbol, decimal quantity,
    decimal takeProfitLimitPrice, decimal stopLossPrice, ...)

Order.GetOrderPrices (Common/Extensions.cs) is the price-reading switch that used to sit inside
ToOrderTicket. It was pulled out so both callers share it, and it is generic: every order type Lean has,
not only the two OCO legs.

Design

The execution type flag

We reuse GroupOrderManager — the class that already links combo order legs together
(Common/Orders/GroupOrderManager.cs). One new enum says how the group executes, in its own file
Common/Orders/GroupExecutionType.cs:

public enum GroupExecutionType
{
    /// <summary>All legs are placed and filled together as one unit (today's combo behavior) (0)</summary>
    Combo = 0,

    /// <summary>One leg fills -> every other leg in the group is canceled (1)</summary>
    OneCancelsTheOther = 1
}

The enum is designed to grow. The next PRs append (never renumber — enum ints are persisted):

  • OneTriggersTheOther = 2 (0003-one-triggers-the-other-order-group.md): the group has a trigger
    leg; the other legs wait until it fills.
  • Bracket = 3 (final PR): trigger leg + children that are OCO between themselves. One enum value, because
    every dispatch point then stays a simple switch — the bracket behavior itself is the OTO step plus the OCO
    step composed.

New serialized property on GroupOrderManager:

[JsonProperty(PropertyName = "executionType", DefaultValueHandling = DefaultValueHandling.Ignore)]
public GroupExecutionType ExecutionType { get; set; }

DefaultValueHandling.Ignore keeps old serialized groups unchanged: they load as Combo, so nothing breaks
on restart.

Why the flag is needed: combo code assumes all legs share one fate (all-or-nothing fills, group expiry, group
price). Every one of those rules is wrong for OCO, where exactly one leg wins. The engine and the plugins need
a cheap way to ask "is this group a combo or something else?" — this flag is that check.

The name was ComboType while the work was in progress. It was renamed on 2026-08-20, before release, because
three of the four planned values are not combos and Lean already uses the word "Combo" for ComboOrder and
OrderType.ComboMarket/ComboLimit/ComboLegLimit.

Group values for an OCO group: Quantity = the main (first) order's signed quantity, Count = number of
legs, LimitPrice unused (0). The legs keep their own real quantities. There is no ratio math: OCO legs
are plain LimitOrder / StopMarketOrder objects, not ComboOrder subclasses.

How the orders flow through the engine

  1. OneCancelsTheOtherOrder creates one GroupOrderManager
    (Transactions.GetIncrementGroupOrderManagerId(), ExecutionType = OneCancelsTheOther) and builds one
    SubmitOrderRequest per leg, all sharing the manager. SubmitOrderRequest already carries a
    GroupOrderManager — no change there.
  2. The transaction handler already buffers group legs until the whole group has arrived and invalidates all
    legs together when one fails (Engine/TransactionHandlers/BrokerageTransactionHandler.cs). Both are reused
    unchanged; the buying-power call inside that range is the one part that changed.
  3. Two serialization fixes, because only the combo order types used to carry the manager through the
    factory and JSON paths, and OCO legs are plain order types:
    • Order.CreateOrder (Common/Orders/Order.cs): attach the request's manager to any order that has none,
      before the Id is set, because the Id setter registers the leg into GroupOrderManager.OrderIds only
      when the manager is already there.
    • OrderJsonConverter: a generic restore for any order type whose JSON carries groupOrderManager, again
      before the Id assignment, plus DeserializeGroupOrderManager now reading ExecutionType /
      executionType. Without the second one the flag is silently erased on the first restart: the group loads
      as Combo, the engine stops treating it as a group, and — because the property serializes with
      DefaultValueHandling.Ignore — the next save writes no field at all, so the OCO marker is gone forever.
  4. Cancel is already group-wide: BacktestingBrokerage.CancelOrder resolves the group and cancels every leg.
    It got one guard: skip legs that are already closed, so a filled leg is not overwritten with Canceled.
    QCAlgorithm.Liquidate and Transactions.CancelOpenOrders stay leg by leg on purpose: each open order
    gets its own cancel, and a leg whose siblings already canceled it simply finds nothing left to do. The
    cost is one refused request per extra leg — an error response in backtesting, and in live an extra call
    to the broker plus a "Brokerage failed to cancel order" line. The alternative, one cancel per group, was
    tried and dropped: it puts group knowledge into two more call sites for a problem that is only noise.

Buying power

Buying power is a shared building block with a per-type rule, and it lives where both backtest and live
pass through:

  • The group check runs at submit time in SecurityPortfolioManager.HasSufficientBuyingPowerForOrder(List<Order>),
    called from the transaction handler in both backtest and live, and again from BacktestingBrokerage.Scan.
    The per-type rule branches there, on ExecutionType — not inside the backtest processor.
  • The branch only fires for a real group: orders.Count > 1. A single order that carries a group manager
    reaches this method from the per-leg update path, and it must keep using the position-group path like it
    always did, or an OCO leg on an option contract would be margined as a naked single order on every update.
  • The rule per type: OCO = check the most expensive leg only (exactly one can execute). OTO (later) =
    trigger only while the trigger is open, then the sum of the children. Bracket (later) = trigger only
    while dormant, then the most expensive exit. This table is the reuse contract for the margin axis.
  • HasSufficientBuyingPowerForOneCancelsTheOtherGroup has two branches. Same symbol on every leg: compare
    notional values and check the most expensive leg. Different symbols: there is no common notional metric
    (an option's premium is not its margin), so every leg has to pass its own check.
  • The OCO branch runs before Positions.TryCreatePositionGroup on purpose. Two option legs on different
    contracts could form a valid strategy there and be margined as if both execute, which is wrong for a
    one-winner group. Same-symbol legs cannot go through the position-group path at all, since that machinery
    keys positions by symbol.
  • CashBuyingPowerModel.GetOpenOrdersReservedQuantity sums the open same-direction Limit/StopMarket orders —
    exactly an OCO pair's shape. Holding 1 BTC and placing the sell-TP + sell-SL story trade, each leg would see
    the sibling already reserving the full 1 BTC and the group would be rejected. Siblings of the same group are
    now excluded from that sum.
  • The same "one winner, don't count both" idea applies to the open-order aggregations.
    GroupOrderExtensions.GetEffectiveOpenQuantityTickets keeps only the largest open leg per OCO group, ties
    broken on the lower order id so the answer never depends on enumeration order. It costs nothing when no
    group is present: it hands back the very same sequence. Two callers use it —
    BrokerageTransactionHandler.GetProjectedHoldings and SecurityTransactionManager.GetOpenOrdersRemainingQuantity,
    which is what QCAlgorithm.Shortable goes through. Without it an OCO exit pair projects double the real
    exposure and can trigger false shortable rejections or oversized execution-model orders.

Update path and existing "manager means combo" assumptions

HandleUpdateOrderRequest used to skip buying-power validation for any grouped order (written for combo ratio
legs). OCO allows per-leg updates, so the skip is narrowed to ExecutionType == Combo; every other group type
is validated like a regular order.

A grep audit of every GroupOrderManager consumer is part of this work: for example PublicBrokerageModel.CanUpdateOrder
rejects any grouped order update with a combo-specific message, and the IB plugin used to key its whole combo
handling off the manager. Each such branch has to decide explicitly what it means for a non-combo group; the
InteractiveBrokers section lists the four places that answered it.

Live safety: no brokerage gate in v1

This is the one dangerous spot of the design. OCO legs are plain Limit/StopMarket orders, so every brokerage
model whitelist accepts them one by one. A live brokerage that knows nothing about OCO receives 2-3 normal
orders and places them without the cancel link. The user thinks the group protects them; really both
orders can fill in full.

v1 ships without a generic gate for this. What protects a live algorithm today:

  • Alpaca rejects a bad group leg by leg inside AlpacaBrokerageModel.CanSubmitOrder (2 legs, US equity,
    Limit or StopMarket, one side, Day/GTC), and its plugin only builds the single OCO request when the group
    really is one.
  • Every other brokerage: nothing. A group sent to a plugin that does not understand it is placed as
    unlinked orders, and the promise that only one leg can execute does not hold.
  • Backtesting and paper need no gate, because the engine simulates the group itself.

Remark: SupportsGroupExecution, removed on purpose

A gate was written, reviewed and then removed on 2026-08-20. It is written down here so the next person
adds it back deliberately, with a reason, instead of rediscovering it.

What it was. One virtual on the brokerage model, keyed by execution type so it would serve OCO now and
OTO/bracket later without any new members:

/// <summary>True when this brokerage can place order groups with the given execution type</summary>
public virtual bool SupportsGroupExecution(GroupExecutionType groupExecutionType) => groupExecutionType == GroupExecutionType.Combo;

It took five places, not one:

Place What it did
IBrokerageModel declared the member with a default body, so existing models kept working
DefaultBrokerageModel the implementation: only Combo passes
AlpacaBrokerageModel, InteractiveBrokersBrokerageModel overrode it to also answer true for OneCancelsTheOther
BrokerageModelPythonWrapper forwarded to supports_group_execution, falling back to the default when a Python model did not define it
BrokerageTransactionHandler checked it once per group, live only (!_brokerageIsBacktesting), and invalidated the whole group with BrokerageModelRefusedToSubmitOrder

It also needed PythonWrapper.ValidateImplementationOf to stop reporting default-bodied interface methods as
missing, so old Python brokerage models kept validating.

Why it was removed. Adding a member to the public IBrokerageModel interface is a heavy, hard-to-take-back
step, and it is spread over five files plus a Python path. It bought nothing for the only live mapping that
exists: Alpaca already refuses what it cannot place, per leg, in CanSubmitOrder. So v1 pays no interface
cost for a case no brokerage needs yet.

When to add it back. When a second brokerage maps order groups, or when OTO/bracket land and more than
one plugin has to answer "can you do this kind of group". At that point the shape above is the starting
point, with two things to decide again:

  • Does it belong on IBrokerageModel at all, or does CanSubmitOrder (which already sees the leg and its
    GroupOrderManager) carry the rule per brokerage, like Alpaca does today?
  • If it does go on the interface, keep the default body, and remember the Python wrapper and
    ValidateImplementationOf have to move with it.

Until then, treat "this brokerage understands order groups" as knowledge that lives in each plugin.

Lifecycle

State walk-through for a 3-leg group (A = main, B, C — these are the engine rules; the user API places the
2-leg case):

# Step A B C Events
0 The group is placed, one ticket per leg New New New none (requests buffered until the last leg)
1 Group validated and placed Submitted Submitted Submitted 3x Submitted
2 Leg B fully fills Canceled Filled Canceled one batch: Filled(B), Canceled(A, "OCO"), Canceled(C, "OCO")
3 User cancels any one leg Canceled Canceled Canceled 3x Canceled (cancel one = cancel all)
4 User updates a leg price unchanged unchanged unchanged UpdateSubmitted on that leg only
5 Time in force expires Canceled Canceled Canceled 3x Canceled ("expired")
6 Submit-time failure on any leg Invalid Invalid Invalid 3x Invalid via InvalidateOrders
7 Leg B partially fills 30 of 100 Submitted, quantity 70 PartiallyFilled Submitted, quantity 70 PartiallyFilled(B); A and C reduced by 30, each with an UpdateSubmitted event

The rules, short version:

  • One group, one size. The group can never execute more than its own quantity.
  • After any execution of X on one leg, every other open leg is reduced by X. A leg reduced to zero is canceled
    in the same event batch.
  • Only one leg can execute per bar. The group stops as soon as a leg produces any fill, full or partial.
  • Cancel one leg = cancel the whole group.
  • Update one leg = only that leg changes.

Partial fills: reduce the siblings, do not cancel them

A leg does not always fill in one piece. The rule is the one InteractiveBrokers uses: take the filled
quantity off the other legs.

The story trade again. You hold 100 shares, take profit at $220, stop loss at $190. The stop fills only 30:

Rule The limit leg becomes Result
Cancel siblings only on a full fill still 100 the group sells 130 — more than you own
Cancel siblings on any fill canceled 70 shares left with no protection and no target
Reduce siblings by the filled amount 100 -> 70 the total can never pass 100, and the rest stays protected

The third one is the only one with no bad side, so that is the rule. It also makes the design smaller, because
"cancel" stops being a separate rule. Canceling on a full fill is just the case where the other legs are
reduced to zero. One rule replaces two:

after a leg executes X, take X off every other open leg; a leg that reaches zero is canceled.

ReduceOpenSiblings has three branches. Two of them fire in normal use:

Branch When What happens
The other leg is Filled it used the whole group quantity every open sibling is canceled
The sibling still has quantity left a partial fill the sibling's quantity drops by the executed amount
The sibling drops to zero or below the legs have different sizes the sibling is canceled

The third one cannot happen through OneCancelsTheOtherOrder, because both legs get the same quantity: a
partial fill always leaves the sibling above zero, and a full fill takes the first branch instead. It becomes
reachable only when a per-leg update changes one leg's size, which the update path now allows. It has no
test
— see "Known gaps".

Details the method settles:

  • A canceled leg keeps the quantity it was submitted with, so it still reports what it was asked for. It is
    canceled rather than left open at zero: a zero-quantity order has no direction and no fill model fills it.
  • The reduce event carries FillQuantity = 0 and status UpdateSubmitted, so the transaction handler filters
    it out before ProcessFills and TradeBuilder — holdings math is untouched.
  • Reducing a leg is a plain assignment: Order.Quantity has an internal setter, QuantConnect.Brokerages
    already has InternalsVisibleTo, and the pending set holds the handler's own instance with no clone.

Backtesting and paper

Home: BacktestingBrokerage.Scan (Brokerages/Backtesting/BacktestingBrokerage.cs). After group resolution
the guard is ExecutionType != Combo, and the type dispatch is a switch with a loud default that invalidates
the group. A lazily created HashSet<int> processedGroupIds makes sure a group is evaluated once per pass and
not once per leg — a group has one entry in the pending set per leg.

Why not in FillModel: fill models are user-replaceable per security and overridable from Python. The OCO
promise ("never more than the group size") must not depend on user code. The fill models keep doing what they
do today — decide the fill price of a single leg. The group logic lives one level up.

Why not in BrokerageTransactionHandler: it is shared with real live trading, where the broker cancels the
losing legs itself. Engine-side cancel there would double-cancel.

ProcessOneCancelsTheOtherGroup returns one bool: true when the group is still open and has to be evaluated
again on a later scan. That is the only thing Scan needs back from it, so the group branch reads
stillNeedsScan |= ProcessOneCancelsTheOtherGroup(orders, securities). Every early exit answers the same
question: a group that was invalidated returns false, a group waiting on a pending cancel or on a later bar
returns true. The method is the composition of:

  1. Leg-type check: only Limit and StopMarket. QCAlgorithm.OneCancelsTheOtherOrder builds exactly those two
    legs, so a group placed through the API can hold nothing else — but a group rebuilt from JSON can carry any
    type, and TryFillLeg only knows how to evaluate a plain Limit/StopMarket fill.
  2. TryOrderPreChecks — reused as is. It already cancels the whole group on time-in-force expiry, so the
    ExpireGroup step this ADR originally planned was deliberately not built.
  3. Buying power for the group, with the CancelPending defer and the try/catch that invalidates the group on
    a margin-model error.
  4. Evaluate the open legs in a fixed order — stop-type legs first, then limit legs, then by Lean order Id.
    Deterministic, and pessimistic when one bar could fill two legs at once: the tie rule the bracket inherits
    for its exits.
  5. TryFillLeg(order, security, securities) per leg — one leg with its security's FillModel, its time in
    force and its fee. Knows nothing about groups.
  6. The first leg that produces any executed quantity ends the pass, full or partial -> ReduceOpenSiblings
    into the same event batch. Stopping on a partial fill too is what keeps a second leg from executing in
    the same bar. Status-only events with no quantity do not reduce anything.
  7. Emit the batch, and drop the group from the pending set only when every leg is closed.

The pending-set invariant: a closed leg stays in the pending set while any sibling is open, and leaves only
when the whole group is closed. If it is missed anywhere, TryGetGroupOrders cannot resolve the group and the
surviving leg becomes uncancelable. Three of the touch points shipped — RemoveOrders and CancelOrder both
skip already-closed legs, and the processor's tail removes the group only when all legs are closed. Two did
not: Scan's purge branch still removes any closed order on sight, and UpdateOrder still accepts anything in
the pending set. Neither is reachable while an OCO group always closes every leg in the same batch, but the
Scan one becomes a real bug the moment OTO or the bracket lands. See the review, §4.6.

Paper trading comes free: PaperBrokerage extends BacktestingBrokerage and reuses Scan.

Built to be reused by OTO and the bracket

The map of every building block, and who consumes it later. The honest claim after the code review: OTO adds
the dormancy/activation step plus a handful of trigger-aware branches; the bracket adds no new mechanism — it
composes the existing steps.

Building block Lives in OCO (this PR) OTO (ADR 0003) Bracket (final PR)
GroupExecutionType + GroupOrderManager.ExecutionType Common shipped, value OneCancelsTheOther = 1 appends OneTriggersTheOther = 2 appends Bracket = 3
Serialization fixes (plain legs carry the group; executionType survives the round trip) Common shipped reused as is reused as is
SubmitGroupOrder shared submitter QCAlgorithm shipped; OCO wrapper new thin wrapper new thin wrapper (the "sugar") + trigger-aware sync wait
Order.GetOrderPrices price reader Common shipped, generic reused as is reused as is
Brokerage gate for order groups Common not in v1, see the remark under "Live safety" add it back when a second brokerage maps groups reused once it exists
Group leg buffering (BrokerageTransactionHandler + GroupOrderCacheManager) existing reused as is reused as is reused as is
Group buying power by execution type + sibling exclusion in CashBuyingPowerModel Common shipped (rule: max leg) new rule: trigger, then sum of children new rule: trigger, then max exit
GetEffectiveOpenQuantityTickets for open-order aggregations Common shipped (max leg per group) extends with dormant-children rule reused as is
Cancel one leg = cancel the group + skip-closed guard BacktestingBrokerage shipped reused as is reused as is
Update path narrowed to ExecutionType == Combo handler shipped reused as is reused as is
TryFillLeg single-leg evaluation BacktestingBrokerage shipped reused as is reused as is
ReduceOpenSiblings (the OCO step) BacktestingBrokerage shipped not used reused on the two exits
Stops-first deterministic leg order BacktestingBrokerage shipped, but inline reused reused (SL before TP on a tie)
Emit + remove-if-closed tail and the pending-set invariant BacktestingBrokerage 3 of 5 touch points needs the other 2 reused as is
Dormancy + activation step (children wait for the trigger) BacktestingBrokerage not needed ships in ADR 0003 reused on the entry->exits switch
Trigger-aware engine branches (Scan same-bar rule; submitter sync wait) BacktestingBrokerage + QCAlgorithm not needed ships in ADR 0003 reused as is

How the three processors read once everything exists (sketch, not code):

switch (group.ExecutionType)
{
    OneCancelsTheOther:  evaluate legs -> first leg with any fill ends the pass -> ReduceOpenSiblings
    OneTriggersTheOther: trigger open?  -> TryFillLeg(trigger); on fill -> activate children
                         trigger filled? -> evaluate children independently (no OCO link)
    Bracket:             trigger open?  -> TryFillLeg(entry);  on fill -> activate children
                         trigger filled? -> evaluate children -> first leg with any fill -> ReduceOpenSiblings
}

Reserved extension point (documented now, shipped in ADR 0003): OTO and the bracket must know which leg is
the trigger
. The plan is one more serialized field on GroupOrderManager — TriggerOrderId (int, 0 = no
trigger, ignored when default) — instead of new order types for the trigger leg. ADR 0001 used new order types
(BracketMarket/BracketLimit) mainly to get live gating for free from the model whitelists; the
gate that job would have covered is gone for now, and plain legs plus TriggerOrderId still keep every leg
reusing the existing order classes end to end. This PR does not add the field — it only keeps every
design choice compatible with it. Note the trap: DeserializeGroupOrderManager rebuilds the manager field by
field, so TriggerOrderId will be dropped on restart unless a line is added there too.

Today SubmitGroupOrder takes the group's quantity and direction from orders[0] and nothing says so. For
OCO both legs share a quantity, so the convention is invisible; for OTO the first leg is the trigger, and a
wrapper that passes children first would give the group a child's quantity. Write the convention down when
ADR 0003 lands, and set TriggerOrderId explicitly instead of leaving the meaning positional.

Live mapping: Alpaca

Repo: Lean.Brokerages.Alpaca, branch feature-alpaca-oco-orders, draft PR #74. Alpaca has a native OCO order
class: one POST /v2/orders with order_class=oco. What shipped:

  • Model rules, per leg (Common/Brokerages/AlpacaBrokerageModel.cs in Lean core).
    CanSubmitOrder runs a per-leg check for the rules Alpaca is stricter about: exactly 2 legs, US equities only, Limit or StopMarket leg types,
    every leg on the same side as the group, and a Day or GoodTilCanceled time in force. Each rejection has its
    own message in Messages.Brokerages. The two rules that need both legs at once — same symbol, one of each
    order type — stay in the plugin, which sees the whole group after it is buffered.
  • Placement (AlpacaBrokerage.PlaceOrder). The legs are buffered with GroupOrderCacheManager, then
    orders.CreateAlpacaOneCancelsTheOtherOrder(_symbolMapper) builds the SDK's typed OneCancelsOtherOrder:
    the Lean limit leg becomes the parent order (type=limit, take profit price in take_profit.limit_price)
    and the Lean stop leg becomes the nested stop_loss.stop_price.
  • Ids. Both Lean legs get their own Alpaca order id before the stream lock releases: the parent id from
    the POST response, the child id from response.Legs, with a single GetOrderAsync fallback when the
    response carries no child (the child id in the POST response is commonly observed but not clearly
    documented). Without this the external-order path would adopt the server-created leg as a foreign order.
  • Stream. Per-leg events route by broker id with no change. TradeEvent.Held is now handled next to
    New/PendingNew: the passive leg is reported as held and never gets a New event first, so without
    registering it there its later canceled event was dropped. And when a leg fills while its sibling is
    already Filled, a BrokerageMessageEvent warning is emitted — Alpaca documents that "in extremely
    volatile and fast market conditions, both orders may fill before the cancellation occurs".
  • Cancel. No new code. DELETE on any leg cancels the group server-side.

Two things Alpaca's own docs leave open, to confirm on paper trading: whether the API rejects an OCO with no
open position (its OCO is described as exit-only), and whether IOC/FOK are accepted. The Alpaca ADR
(Documentation/Lean.Brokerages.Alpaca/ADR/0001-one-cancels-the-other-orders.md) holds the full mapping,
the answers from Alpaca support, and the known restart-reconciliation gap.

Live mapping: InteractiveBrokers

Repo: Lean.Brokerages.InteractiveBrokers, branch feature-oco-orders, draft PR #244. IB has no single
"OCO request". Every leg is a normal order, and two fields tie the legs together: OcaGroup, a text label
shared by every leg, and OcaType, what happens when one leg executes. What shipped:

  • Placement. Each leg is its own IB order: its own id, its own plain contract (not a BAG) and its own
    placeOrder call. IBPlaceOrder loops over the buffered legs and places them one by one. The label is
    built from the Lean group id, lean-oco-{groupOrderManagerId}. There is no ParentId: that is a
    bracket/OTO tool, not an OCO tool.
  • Restart. GetOpenOrders puts orders that share an OcaGroup label back into one Lean group with a
    rebuilt GroupOrderManager. A label with only one order left is not treated as a group.
  • Update. One leg updates on its own. The combo path waits for a sibling update to arrive too; an OCO
    leg does not.
  • Combo-only paths, narrowed. EmitOrderFill only waits for every leg when the group is a real combo —
    in an OCO group the losing legs never fill, so the buffer would always run into its timeout. The shared
    combo contract cache and the group quantity/direction rule are narrowed the same way: an OCO leg keeps its
    own quantity and direction, there is no ratio math.

Three things came out differently from the plan in this document, all confirmed against a live IB Gateway:

Planned here What shipped Why
OcaType = 2, reduce the remaining legs OcaType = 1, cancel the remaining legs Type 1 also routes only one leg at a time, which is what blocks an overfill
Transmit = false on every leg but the last Transmit = true on every leg A held order is a TWS window feature: a grey row a person clicks. Gateway has no window, so the held legs never activated and only the last leg reached the market
Losing legs report Canceled a losing leg can report Invalid IB answers a leg sent after the group already filled with error 201, "OCA group is already filled", status Inactive, which Lean maps to Invalid

The OcaType difference matters and is not settled. The engine reduces the siblings by the executed
quantity (see "Partial fills"); IB type 1 cancels them instead. So a partial fill on one leg leaves the rest
of the position protected in a backtest and unprotected live. Either the engine rule or the IB mapping has
to move before this ships.

The other two are lower risk. The Transmit finding is a plain correction — the official sample only shows
that technique in VB, and it does not work headless. The Invalid reporting only appears when a leg can fill
instantly, so a leg placed after it is refused; two resting exit legs, the normal case, all reach the market
first and the winner cancels the rest properly. Still worth deciding whether error 201 should map to
Canceled for a leg of an OCO group.

The IB-side ADR (in that repo, Documentation/ADR/0001-one-cancels-the-other-orders.md) holds the full
mapping, the real Gateway log of a partial fill and the per-file list of changes.

Unit tests

  • OrderJsonConverterTests: a plain LimitOrder with an OCO group survives two consecutive round trips
    (the second catches the DeserializeGroupOrderManager drop combined with DefaultValueHandling.Ignore);
    old JSON without the field loads as Combo.
  • OrderTests: Order.CreateOrder attaches the manager to a plain leg before the Id is set, so the leg
    registers itself into OrderIds.
  • AlgorithmTradingTests: the two tickets come back limit-first and share one manager with
    GroupExecutionType.OneCancelsTheOther; zero quantity returns one invalid ticket with
    OrderResponseErrorCode.ZeroQuantity; the method-level tag and orderProperties land on both legs;
    Liquidate cancels every leg of an open group and does not throw.
  • BacktestingBrokerageTests: LegFillCancelsSiblingInSameEventBatch, StopLegWinsTieOverLimitLeg,
    CancelingOneLegCancelsWholeGroup, CancelOrderLeavesAlreadyClosedLegUntouched,
    TimeInForceExpiryOnAnyLegCancelsWholeGroup, PartialFillReducesSiblingsAndGroupStaysPending,
    GroupIsProcessedOnlyOnceExactlyPerScanDespiteTwoPendingEntries. These run the same code paths OTO and the
    bracket will run, so this suite is the safety net for the next two PRs too.
  • BrokerageTransactionHandlerTests: legs buffer until the group is complete; a submit-time failure
    invalidates every leg; the update path validates buying power for OCO but not for combo.
  • AlpacaBrokerageModelTests: a valid group passes CanSubmitOrder, and each of the five per-leg rules
    rejects on its own.
  • Buying power tests: a cash account holding exactly 1 BTC can place the sell take profit + sell stop loss
    pair through OneCancelsTheOtherOrder itself, so the whole submit path runs and the sibling exclusion is
    what makes it pass; the group check uses the most expensive leg; same-symbol legs skip the position-group
    path.

Regression algorithms

Three self-verifying algorithms on SPY hourly, January 2019 (the data window the repo already uses for
order-type regressions). The first two ship as C# + Python twins; the third is C# only, because it needs a
custom fill model.

OneCancelsTheOtherOrderRegressionAlgorithm — the winner path: buy 100 SPY, then place the exit pair with the
take profit +1% (reached by the January rally) and the stop -30% (never reachable). Asserts the limit leg
fills, the stop leg ends Canceled, and the algorithm ends flat.

OneCancelsTheOtherOrderCancelRegressionAlgorithm — the cancel path: place the group with both prices 30%
away, cancel one ticket a few days later, assert both legs end Canceled and the original market order
fill is untouched.

OneCancelsTheOtherOrderPartialFillRegressionAlgorithm — the reduce path (C# only). A custom fill model
executes the stop leg once, partially, and then goes quiet, so the limit leg finishes the group. Neither leg
looks at the market price, so the case runs the same way every time. The stop executes 30 of the 100 shares,
so the limit leg must shrink to 70 and sell only 70 — the assertion is on the leg's quantity, not just the
totals, because a group that simply stopped early would also end flat.

Two things that algorithm does deliberately: it throws from OnOrderEvent rather than from the fill model,
because TryFillLeg catches fill-model exceptions into Algorithm.Error where they are not a test failure;
and both fill overrides check IsExchangeOpen, so a bar outside market hours cannot let the limit leg execute
on its own.

None of the three round their prices. Lean rounds order prices to the brokerage's precision before sending
them.

Open questions

  • Max legs and API width. The engine has no leg-count guard; only the public method signature limits us to
    2. Keep it narrow or open it up? The blocker to going wide is margin, not the group plumbing. Written up in
    0002-open-question-group-scope.md, waiting on review.
  • Different symbols in one group. The engine allows it and has its own buying-power branch for it; the
    Alpaca model restricts to one symbol. Is per-leg checking the right rule for the multi-symbol case?
  • Trigger leg marker for OTO/bracket. GroupOrderManager.TriggerOrderId (plain legs, no new order types)
    instead of ADR 0001's BracketMarket/BracketLimit. Trade-off to confirm in ADR 0003: plain legs reuse
    every existing code path, but new types make the entry leg visible in OrderType dispatches and reports.
  • Alpaca semantics. Alpaca's OCO reads as exit-only. Verify on paper trading that a fresh OCO with no
    position is rejected, and mirror that rejection message in the model.
  • UpdateOrder on a live OCO leg. Lean now allows per-leg updates. Whether Alpaca's PATCH endpoint updates
    one leg of a live group without disturbing the sibling is unverified.

Decided since the proposal:

  • Partial fills reduce the siblings instead of canceling them. The IB mapping does not match this yet, it uses
    OcaType = 1 and cancels them — still to settle.
  • The enum is GroupExecutionType, not ComboType.
  • Canceling a group from the algorithm side stays leg by leg. Liquidate and CancelOpenOrders send one
    cancel per open order and let the extra ones fail harmlessly, instead of carrying a per-group skip.
  • No brokerage gate in v1. SupportsGroupExecution is not added to IBrokerageModel. The removed shape and
    the conditions for bringing it back are recorded under "Live safety: no brokerage gate in v1".

Known gaps carried from the code review

Full detail in 0002-implementation-review.md. Still open at the time of
writing:

  • No brokerage gate. A plugin that does not understand order groups places the legs unlinked and both can
    fill. Alpaca and InteractiveBrokers both map the group themselves; nothing guards any other brokerage. A
    deliberate v1 choice — see "Live safety: no brokerage gate in v1".
  • IB cancels the siblings where the engine reduces them (OcaType = 1). A partial fill behaves
    differently live and in a backtest — see "Live mapping: InteractiveBrokers".
  • Cash accounts over-reserve against a resting OCO pair. The sibling exclusion only helps when the order
    being checked is itself a leg of that group. Any other order still counts both legs (review §3.2).
  • Two of the five pending-set touch points are missing (review §4.6).
  • The reduce-to-zero branch of ReduceOpenSiblings has no test. Only reachable when a per-leg update
    gives the legs different sizes. The whole per-leg update path has no brokerage-level coverage (review §6).
  • Code motion the reuse table promises but does not have yet: the emit/remove tail is written twice with
    two different rules, the group buying-power block is duplicated, SubmitGroupOrder repeats half of
    SubmitComboOrder, and the leg-type allowlist plus the stops-first order are inline instead of named steps
    (review §2.1-2.3, §4.2, §4.4, §4.5). The stops-first key also sorts StopLimit and TrailingStop into the
    limit bucket, which matters as soon as the allowlist widens.
  • Not part of this feature: Logging/CompositeLogHandler.cs and Tests/Brokerages/BrokerageTests.cs.
    Closed 2026-08-20 — both files are back to master, so the pull request no longer touches logging.

@Romazes Romazes closed this Aug 25, 2026
@Romazes
Romazes deleted the feature-8253-oco-order branch August 27, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant